Skip to content

ble: offer to clear a stale phone pairing, once, after asking first - #1790

Merged
ryanbr merged 3 commits into
mainfrom
feat/stale-bond-removal
Sep 1, 2026
Merged

ryanbr merged 3 commits into
mainfrom
feat/stale-bond-removal

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Ported as a fact from OpenStrap/edge's gen5 bootstrap, which removes the platform bond once after five consecutive exchange failures and clears the counter only when a connect reaches READY. edge is Dart; none of its code is here.

What it does

NOOP has always detected the stale bond — a bonded fast-path connect that drops before it ever reaches a session means the OS holds a pairing the strap no longer honours (a firmware update, or the official WHOOP app re-bonding). From the second failure it shows the forget-and-re-pair guide, whose step 2 is:

Open Settings → Bluetooth, find your WHOOP, and Forget / Unpair it.

This does that step for the user when asking did not work. Field evidence it is reachable: log 260901-1022 shows stale OS bond (attempt 1) and (attempt 2) on a real strap.

Why it is opt-in and default off

removeBond() deletes a pairing from the phone's Bluetooth settings. That is at least as consequential as createBond() forming one, which shouldRequestExplicitBond already gates behind its own switch — and ExplicitBond's doc states the rule outright: a probe that changes OS state "must not ride in on consent given for something else."

The key joins FIVE_MG_GATED_KEYS, whose own doc warns that two lists would drift.

The thresholds, and why they differ from the guide's

Five failures, not the two where the guide appears. The guide asks the user to act; they deserve several reconnect cycles to do it before the app acts for them. A pairing that really is stale reaches five within a couple of minutes.

Once per streak, cleared only by a genuine bond. Removing a pairing and immediately failing again means the pairing was not the problem, and repeating would delete one per reconnect for a cause it cannot fix. A genuine bond is the event that proves phone and strap agree again.

Honest about what the OS may say

removeBond() is not public API, so it goes through reflection and a refusal is a normal outcome, not a crash. The log distinguishes them:

Stale pairing: cleared … Android accepted the request; the next connect starts from an unpaired state…
Stale pairing: asked Android to clear … and it REFUSED … Nothing has changed - clear it yourself…

"We asked" and "it happened" are different facts, and a user reading the line needs to know which they got. No MAC, no serial — pinned by a test that matches the shape of a MAC rather than the : character, since the prose itself contains colons.

Guards

Each clause is pinned separately, because getting one wrong means removing a working pairing:

  • not opted in → never, not even far past the threshold
  • WHOOP 4 → never; it bonds by the route it always has
  • OS holds no bond → nothing to remove
  • below threshold → asserted for every value 0..4
  • already removed this streak → never twice

isWhoop5 is belt and braces: staleDirectBond can only be set by the Easy-connect path, which is 5/MG-only and pins the model. It is kept because connectedFamily fails closed when stale — the direction that matters is never removing a pairing we should not.

Parity

Android-only, and permanently so. CoreBluetooth exposes no bond-removal API at all, so there is nothing to twin — the same situation ExplicitBond and BondStateTrace document with their PARITY: notes.

Verification

  • 5025 unit tests, 0 failures; 8 new in StaleBondRemovalTest.
  • lintVitalFullRelease (fatal ExtraTranslation) clean, and Tools/i18n_audit.py exits 0.
  • The switch label is localized in all six Android locales — the German resource test caught the omission when only values/ had it.
  • Tools/doc_comment_lint.py clean.

What it will not do

It does not make a strap that refuses SMP pair. On a 5/MG answering Pairing Not Supported (0x05) this clears a stale pairing so the failing bonded fast-path stops being attempted — it does not reach the handshake. #1635's designed end state is unchanged.

NOOP has always DETECTED the stale bond - a bonded fast-path connect that drops
before it ever reaches a session means the OS holds a pairing the strap no
longer honours - and from the second failure it shows the forget-and-re-pair
guide. Step 2 of that guide is "Open Settings > Bluetooth, find your WHOOP, and
Forget". This does that step for the user when asking did not work.

Field evidence it is reachable: log 260901-1022 shows `stale OS bond (attempt
1)` and `(attempt 2)` on a real strap.

OFF by default, behind its own switch, per the rule every strap-affecting probe
in PuffinExperiment follows. removeBond() DELETES a pairing from the phone's
Bluetooth settings - at least as consequential as createBond() forming one,
which shouldRequestExplicitBond already gates the same way - so it must not ride
in on consent given for something else. The key joins FIVE_MG_GATED_KEYS, whose
doc says two lists would drift.

Five failures, not the two where the guide appears: the guide asks the user to
act and they deserve cycles to do it before the app acts for them. Once per
streak, cleared only by a genuine bond - removing a pairing and failing again
means the pairing was not the problem, and repeating would delete one per
reconnect for a cause it cannot fix.

removeBond() is not public API, so it goes through reflection and a refusal is a
normal outcome: the log says whether Android ACCEPTED or REFUSED, because "we
asked" and "it happened" are different facts and a user reading the line needs
to know which they got. No MAC, no serial.

The isWhoop5 guard is belt and braces - staleDirectBond can only be set by the
Easy-connect path, which is 5/MG-only - and it is kept because connectedFamily
fails CLOSED when stale. The direction that matters is never removing a pairing
we should not.

Threshold and once-only shape are taken as a FACT from OpenStrap/edge's gen5
bootstrap, which removes the platform bond once at five consecutive exchange
failures and clears the counter only on a connect reaching READY. edge is Dart
and none of its code is here.

Android-only: CoreBluetooth exposes no bond-removal API at all, so there is
nothing to twin. 5025 unit tests green; release lintVital and the i18n audit
clean; the switch label is localized in all six locales.
Re-review of my own PR: the row went into SettingsScreen, inside

    if (false && showFiveMGControls) {

whose comment says it outright - "Developer-only 5/MG controls now live in Test
Centre. Keep the implementation below during the compatibility transition, but
never render a second copy in everyday Settings." I had added a toggle nobody
could reach, next to an explicitBond switch that is equally unreachable and only
appears to work because the pref persists from before that block was disabled.

Moved to TestCentreScreen, beside "Ask Android to pair" and "Try history sync
without pairing" - the two switches this escalates from, now sitting together
where a reader meets them in one place. Inside , which is live.

That also corrects the string. Test Centre titles use the raw_diag_* namespace,
so the l10n_settings_screen_* key is gone from all seven files and replaced by
raw_diag_clear_stale_bond, translated in the same six locales. The developer
detail beside it stays an English literal, as every sibling row's does.

Nothing was added to the dead block: a row that never shipped in Settings needs
no compatibility copy, and adding one would only be more to delete later.

5025 unit tests green; release lintVital, the i18n audit and doc lint clean.
The bug the last commit fixed had no test that could have caught it. Every
assertion passed and all four checks were green while the toggle sat in
SettingsScreen's `if (false && showFiveMGControls)` block, unreachable. A UI
switch has no assertion that says "a user can get to me".

So this reads the source, the way SystemInitiatedConnectTest does for connect
entry points: the toggle must be wired in Test Centre and bound to its
experiment, Test Centre must not have grown a disabled block of its own, and the
retained-but-dead Settings copy must not have gained it back.

Verified it can fail by unwiring the toggle and watching this one assertion go
red, then restoring. It fails loudly rather than skipping when the source is not
found, for the same reason the push entry-point gate does.

5026 unit tests green.
@ryanbr
ryanbr merged commit 7ab0588 into main Sep 1, 2026
4 checks passed
@ryanbr
ryanbr deleted the feat/stale-bond-removal branch September 1, 2026 07:14
ryanbr added a commit that referenced this pull request Sep 1, 2026
A mirror of #1790 is not safely buildable. This records why, and corrects a
claim I made in that PR's merge body.

#1790 said "Android-only and permanently so: CoreBluetooth exposes no
bond-removal API." True, and not the whole blocker - stated that way it reads as
settled and invites the next reader to go hunting for a way round.

The real obstacle is IDENTITY. IOBluetooth, whatever it can or cannot do to a
BLE pairing, addresses devices by MAC, and Apple's side never has one:
BLEManager.epitaphLine's own doc already says the peripheral UUID is
"per-install, not the hardware address". Grepped BLEManager unfiltered to be
sure - the only address mentions are that doc line and the new note. The one
bridge left is matching IOBluetoothDevice.pairedDevices() by NAME, and deleting
a pairing on a name match removes the wrong device the moment a user has two
straps or has renamed one. That is exactly the mistake shouldRemoveStaleBond's
guards exist to prevent, so closing the gap that way is worse than leaving it
open.

Worth writing down because Apple is not unaware of the condition:
didFailToConnect already handles CBError.peerRemovedPairingInformation and
publishes the same forget-and-re-pair guide, naming the strap. Both platforms
detect it, both give the same advice, only one can take the step.

Three re-review passes, all on this note's own prose rather than the conclusion.
It asserted an Apple SDK capability inside a correction about unverified
assertions; it re-derived a fact the codebase already stated; and it held iOS to
a looser standard than macOS, including a claim about Apple's design intent.
Each is gone. The technical conclusion never moved.

Notes only, one per platform, cross-referencing. 638 BLE tests green.

Left open deliberately: Apple's stale-bond branch sets the guide and returns,
scheduling nothing, where Android schedules a reconnect - so a user who clears
the pairing in System Settings while the app is open is not picked up until they
reconnect by hand. That may well be deliberate, and it is not a change to make
on one reading of it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant